Micron Document
🎖️GitЯра🎖️

Node / meshtastic / Meshtastic-Android / files / core / data / src / commonMain / kotlin / org / meshtastic / core / data / manager / MeshConnectionManagerImpl.kt

Displaying Raw • Download

core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt 5235b8fc51eb5f46d453effb2deec00bcc6abdbb (5235b8fc) Text, 40.56 KB

T8b949e/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
Tff7b72package T7ee787org.meshtastic.core.data.manager

Tff7b72import T7ee787co.touchlab.kermit.Logger
Tff7b72import T7ee787co.touchlab.kermit.Severity
Tff7b72import T7ee787kotlinx.atomicfu.atomic
Tff7b72import T7ee787kotlinx.coroutines.CancellationException
Tff7b72import T7ee787kotlinx.coroutines.CoroutineScope
Tff7b72import T7ee787kotlinx.coroutines.Job
Tff7b72import T7ee787kotlinx.coroutines.delay
Tff7b72import T7ee787kotlinx.coroutines.flow.first
Tff7b72import T7ee787kotlinx.coroutines.flow.launchIn
Tff7b72import T7ee787kotlinx.coroutines.flow.onEach
Tff7b72import T7ee787kotlinx.coroutines.launch
Tff7b72import T7ee787kotlinx.coroutines.sync.Mutex
Tff7b72import T7ee787kotlinx.coroutines.sync.withLock
Tff7b72import T7ee787org.koin.core.annotation.Named
Tff7b72import T7ee787org.koin.core.annotation.Single
Tff7b72import T7ee787org.meshtastic.core.common.util.handledLaunch
Tff7b72import T7ee787org.meshtastic.core.common.util.nowMillis
Tff7b72import T7ee787org.meshtastic.core.common.util.nowSeconds
Tff7b72import T7ee787org.meshtastic.core.common.util.safeCatchingAll
Tff7b72import T7ee787org.meshtastic.core.model.ConnectionState
Tff7b72import T7ee787org.meshtastic.core.model.DeviceType
Tff7b72import T7ee787org.meshtastic.core.model.TelemetryType
Tff7b72import T7ee787org.meshtastic.core.repository.AppWidgetUpdater
Tff7b72import T7ee787org.meshtastic.core.repository.CommandSender
Tff7b72import T7ee787org.meshtastic.core.repository.DataPair
Tff7b72import T7ee787org.meshtastic.core.repository.HandshakeConstants
Tff7b72import T7ee787org.meshtastic.core.repository.HistoryManager
Tff7b72import T7ee787org.meshtastic.core.repository.LockdownCoordinator
Tff7b72import T7ee787org.meshtastic.core.repository.MeshConnectionManager
Tff7b72import T7ee787org.meshtastic.core.repository.MeshLocationManager
Tff7b72import T7ee787org.meshtastic.core.repository.MeshNotificationManager
Tff7b72import T7ee787org.meshtastic.core.repository.MeshWorkerManager
Tff7b72import T7ee787org.meshtastic.core.repository.MqttManager
Tff7b72import T7ee787org.meshtastic.core.repository.NodeManager
Tff7b72import T7ee787org.meshtastic.core.repository.NodeRepository
Tff7b72import T7ee787org.meshtastic.core.repository.NodeRestartTracker
Tff7b72import T7ee787org.meshtastic.core.repository.PacketHandler
Tff7b72import T7ee787org.meshtastic.core.repository.PacketRepository
Tff7b72import T7ee787org.meshtastic.core.repository.PlatformAnalytics
Tff7b72import T7ee787org.meshtastic.core.repository.RadioConfigRepository
Tff7b72import T7ee787org.meshtastic.core.repository.RadioInterfaceService
Tff7b72import T7ee787org.meshtastic.core.repository.ServiceRepository
Tff7b72import T7ee787org.meshtastic.core.repository.SessionManager
Tff7b72import T7ee787org.meshtastic.core.repository.UiPrefs
Tff7b72import T7ee787org.meshtastic.core.resources.Res
Tff7b72import T7ee787org.meshtastic.core.resources.error_recovery_exhausted
Tff7b72import T7ee787org.meshtastic.core.resources.getStringSuspend
Tff7b72import T7ee787org.meshtastic.proto.AdminMessage
Tff7b72import T7ee787org.meshtastic.proto.Config
Tff7b72import T7ee787org.meshtastic.proto.Telemetry
Tff7b72import T7ee787org.meshtastic.proto.ToRadio
Tff7b72import T7ee787kotlin.time.Duration
Tff7b72import T7ee787kotlin.time.Duration.Companion.milliseconds
Tff7b72import T7ee787kotlin.time.Duration.Companion.seconds
Tff7b72import T7ee787kotlin.time.DurationUnit

Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffLongParameterListTa5d6ff"Tb4b4b4, Ta5d6ff"Ta5d6ffTooManyFunctionsTa5d6ff"Tb4b4b4)
Tf0883e@Single
Tff7b72class T56d364MeshConnectionManagerImplTb4b4b4(
Tff7b72private Tff7b72val Te6edf3radioInterfaceServiceTb4b4b4: Te6edf3RadioInterfaceServiceTb4b4b4,
Tff7b72private Tff7b72val Te6edf3serviceRepositoryTb4b4b4: Te6edf3ServiceRepositoryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3serviceNotificationsTb4b4b4: Te6edf3MeshNotificationManagerTb4b4b4,
Tff7b72private Tff7b72val Te6edf3uiPrefsTb4b4b4: Te6edf3UiPrefsTb4b4b4,
Tff7b72private Tff7b72val Te6edf3packetHandlerTb4b4b4: Te6edf3PacketHandlerTb4b4b4,
Tff7b72private Tff7b72val Te6edf3nodeRepositoryTb4b4b4: Te6edf3NodeRepositoryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3locationManagerTb4b4b4: Te6edf3MeshLocationManagerTb4b4b4,
Tff7b72private Tff7b72val Te6edf3mqttManagerTb4b4b4: Te6edf3MqttManagerTb4b4b4,
Tff7b72private Tff7b72val Te6edf3historyManagerTb4b4b4: Te6edf3HistoryManagerTb4b4b4,
Tff7b72private Tff7b72val Te6edf3radioConfigRepositoryTb4b4b4: Te6edf3RadioConfigRepositoryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3commandSenderTb4b4b4: Te6edf3CommandSenderTb4b4b4,
Tff7b72private Tff7b72val Te6edf3sessionManagerTb4b4b4: Te6edf3SessionManagerTb4b4b4,
Tff7b72private Tff7b72val Te6edf3nodeManagerTb4b4b4: Te6edf3NodeManagerTb4b4b4,
Tff7b72private Tff7b72val Te6edf3analyticsTb4b4b4: Te6edf3PlatformAnalyticsTb4b4b4,
Tff7b72private Tff7b72val Te6edf3packetRepositoryTb4b4b4: Te6edf3PacketRepositoryTb4b4b4,
Tff7b72private Tff7b72val Te6edf3workerManagerTb4b4b4: Te6edf3MeshWorkerManagerTb4b4b4,
Tff7b72private Tff7b72val Te6edf3appWidgetUpdaterTb4b4b4: Te6edf3AppWidgetUpdaterTb4b4b4,
Tff7b72private Tff7b72val Te6edf3heartbeatSenderTb4b4b4: Te6edf3DataLayerHeartbeatSenderTb4b4b4,
Tff7b72private Tff7b72val Te6edf3lockdownCoordinatorTb4b4b4: Te6edf3LockdownCoordinatorTb4b4b4,
Tf0883e@NamedTb4b4b4(Ta5d6ff"Ta5d6ffServiceScopeTa5d6ff"Tb4b4b4) Tff7b72private Tff7b72val Te6edf3scopeTb4b4b4: Te6edf3CoroutineScopeTb4b4b4,
Tff7b72private Tff7b72val Te6edf3nodeRestartTrackerTb4b4b4: Te6edf3NodeRestartTrackerTb4b4b4,
Tb4b4b4) Tb4b4b4: Te6edf3MeshConnectionManager Tb4b4b4{
T8b949e/**
* Serializes [onConnectionChanged] to prevent TOCTOU races when multiple coroutines emit state transitions
* concurrently (e.g. flow collector vs. sleep-timeout coroutine).
*/
Tff7b72private Tff7b72val Te6edf3connectionMutex Tff7b72= Te6edf3MutexTb4b4b4(Tb4b4b4)

Tff7b72private Tff7b72var Te6edf3preHandshakeJobTb4b4b4: Te6edf3Job? Tff7b72= Tff7b72null
Tff7b72private Tff7b72var Te6edf3sleepTimeoutTb4b4b4: Te6edf3Job? Tff7b72= Tff7b72null
Tff7b72private Tff7b72var Te6edf3locationRequestsJobTb4b4b4: Te6edf3Job? Tff7b72= Tff7b72null

Tff7b72private Tff7b72val Te6edf3handshakeTimeout Tff7b72= Te6edf3atomicTff7b72<Te6edf3Job?Tff7b72>Tb4b4b4(Tff7b72nullTb4b4b4)

T8b949e/**
* One-way latch set by [onHandshakeComplete] and cleared at the start of each fresh handshake in [handleConnected].
*
* Prevents late-arriving handshake-progress packets (e.g. a FileInfo that lands between NODE_INFO_NONCE and the
* completion of the async Room DB install) from re-arming the fast watchdog that [onHandshakeComplete] already
* cancelled. Without this latch, those late packets would re-introduce the slow-DB false-trip on large meshes that
* [onHandshakeComplete] was added to prevent: the app state is still Connecting during that window, so the existing
* Connecting-state guard inside [onHandshakeProgress] is insufficient on its own.
*/
Tff7b72private Tff7b72val Te6edf3handshakeCompleteLatch Tff7b72= Te6edf3atomicTb4b4b4(Tff7b72falseTb4b4b4)

T8b949e/**
* Consecutive handshake-recovery failure count for [runSiblingHandshakeRecovery].
*
* Incremented atomically on each recovery attempt. Reset to 0 by [onHandshakeComplete] (a successful handshake
* breaks the failure streak) and also reset when the cap is reached and a sticky error is surfaced (so a manual
* user retry starts fresh). When this reaches [MAX_CONSECUTIVE_RECOVERY_FAILURES], recovery stops and the sticky
* error is surfaced instead of silently retrying indefinitely.
*/
Tff7b72private Tff7b72val Te6edf3consecutiveRecoveryFailures Tff7b72= Te6edf3atomicTb4b4b4(T79c0ff0Tb4b4b4)

Tff7b72private Tff7b72var Te6edf3connectTimeMsec Tff7b72= T79c0ff0L
Tff7b72private Tff7b72var Te6edf3connectionRestored Tff7b72= Tff7b72false

Tff7b72init Tb4b4b4{
T8b949e// Bridge transport-level state into the canonical app-level state.
T8b949e// This is the ONLY consumer of RadioInterfaceService.connectionState — it applies
T8b949e// light-sleep policy and handshake awareness before writing to ServiceRepository.
Te6edf3radioInterfaceServiceTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3onEachTb4b4b4(Tff7b72::Te6edf3onRadioConnectionStateTb4b4b4)Tb4b4b4.Te6edf3launchInTb4b4b4(Te6edf3scopeTb4b4b4)

T8b949e// Ensure notification title and content stay in sync with state changes
Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3onEach Tb4b4b4{ Te6edf3updateStatusNotificationTb4b4b4(Tb4b4b4) Tb4b4b4}Tb4b4b4.Te6edf3launchInTb4b4b4(Te6edf3scopeTb4b4b4)

T8b949e// An expected node restart ends when the post-reboot config handshake completes.
Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionState
Tb4b4b4.Te6edf3onEach Tb4b4b4{ Tff7b72if Tb4b4b4(Tffa657it Tff7b72=Tff7b72= Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4) Te6edf3nodeRestartTrackerTb4b4b4.Te6edf3onConnectedTb4b4b4(Tb4b4b4) Tb4b4b4}
Tb4b4b4.Te6edf3launchInTb4b4b4(Te6edf3scopeTb4b4b4)

Te6edf3scopeTb4b4b4.Te6edf3launch Tb4b4b4{
Tff7b72try Tb4b4b4{
Te6edf3appWidgetUpdaterTb4b4b4.Te6edf3updateAllTb4b4b4(Tb4b4b4)
Tb4b4b4} Tff7b72catch Tb4b4b4(Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffTooGenericExceptionCaughtTa5d6ff"Tb4b4b4) Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3eTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ffFailed to kickstart LocalStatsWidgetTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Te6edf3nodeRepositoryTb4b4b4.Te6edf3myNodeInfo
Tb4b4b4.Te6edf3onEach Tb4b4b4{ Te6edf3myNodeEntity Tff7b72-Tff7b72>
Te6edf3locationRequestsJobTff7b72?.Te6edf3cancelTb4b4b4(Tb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3myNodeEntity Tff7b72!Tff7b72= Tff7b72nullTb4b4b4) Tb4b4b4{
Te6edf3locationRequestsJob Tff7b72=
Te6edf3uiPrefs
Tb4b4b4.Te6edf3shouldProvideNodeLocationTb4b4b4(Te6edf3myNodeEntityTb4b4b4.Te6edf3myNodeNumTb4b4b4)
Tb4b4b4.Te6edf3onEach Tb4b4b4{ Te6edf3shouldProvide Tff7b72-Tff7b72>
Tff7b72if Tb4b4b4(Te6edf3shouldProvideTb4b4b4) Tb4b4b4{
Te6edf3locationManagerTb4b4b4.Te6edf3startTb4b4b4(Te6edf3scopeTb4b4b4) Tb4b4b4{ Te6edf3pos Tff7b72-Tff7b72> Te6edf3commandSenderTb4b4b4.Te6edf3sendPositionTb4b4b4(Te6edf3posTb4b4b4) Tb4b4b4}
Tb4b4b4} Tff7b72else Tb4b4b4{
Te6edf3locationManagerTb4b4b4.Te6edf3stopTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}
Tb4b4b4.Te6edf3launchInTb4b4b4(Te6edf3scopeTb4b4b4)
Tb4b4b4}
Tb4b4b4}
Tb4b4b4.Te6edf3launchInTb4b4b4(Te6edf3scopeTb4b4b4)
Tb4b4b4}

T8b949e/**
* Bridges a transport-level [ConnectionState] into the canonical app-level state.
*
* Applies light-sleep policy (power-saving / router role) to decide whether a [ConnectionState.DeviceSleep] event
* should be surfaced as sleep or as a full disconnect, then delegates to [onConnectionChanged] for the actual state
* transition.
*/
Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffonRadioConnectionStateTb4b4b4(Te6edf3newStateTb4b4b4: Te6edf3ConnectionStateTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3localConfig Tff7b72= Te6edf3radioConfigRepositoryTb4b4b4.Te6edf3localConfigFlowTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3isRouter Tff7b72= Te6edf3localConfigTb4b4b4.Te6edf3deviceTff7b72?.Te6edf3role Tff7b72=Tff7b72= Te6edf3ConfigTb4b4b4.Te6edf3DeviceConfigTb4b4b4.Te6edf3RoleTb4b4b4.Te6edf3ROUTER
Tff7b72val Te6edf3lsEnabled Tff7b72= Te6edf3localConfigTb4b4b4.Te6edf3powerTff7b72?.Te6edf3is_power_saving Tff7b72=Tff7b72= Tff7b72true Tff7b72|Tff7b72| Te6edf3isRouter

Tff7b72val Te6edf3effectiveState Tff7b72=
Tff7b72when Tb4b4b4(Te6edf3newStateTb4b4b4) Tb4b4b4{
Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3Connected Tff7b72-Tff7b72> Te6edf3ConnectionStateTb4b4b4.Te6edf3Connected

Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3DeviceSleep Tff7b72-Tff7b72>
Tff7b72if Tb4b4b4(Te6edf3lsEnabledTb4b4b4) Te6edf3ConnectionStateTb4b4b4.Te6edf3DeviceSleep Tff7b72else Te6edf3ConnectionStateTb4b4b4.Te6edf3Disconnected

Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3Connecting Tff7b72-Tff7b72> Te6edf3ConnectionStateTb4b4b4.Te6edf3Connecting

Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3Disconnected Tff7b72-Tff7b72> Te6edf3ConnectionStateTb4b4b4.Te6edf3Disconnected
Tb4b4b4}
Te6edf3onConnectionChangedTb4b4b4(Te6edf3effectiveStateTb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72suspend Tff7b72fun Td2a8ffonConnectionChangedTb4b4b4(Te6edf3cTb4b4b4: Te6edf3ConnectionStateTb4b4b4, Te6edf3fromStateTb4b4b4: Te6edf3ConnectionState? Tff7b72= Tff7b72nullTb4b4b4)Tb4b4b4: Tffa657Boolean Tff7b72=
Te6edf3connectionMutexTb4b4b4.Te6edf3withLock Tb4b4b4{
Tff7b72val Te6edf3current Tff7b72= Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value
Tff7b72if Tb4b4b4(Te6edf3fromState Tff7b72!Tff7b72= Tff7b72null Tff7b72&Tff7b72& Te6edf3current Tff7b72!Tff7b72= Te6edf3fromStateTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3d Tb4b4b4{ Ta5d6ff"Ta5d6ffSkipping connection transition Tffd700$Te6edf3currentTa5d6ff -> Tffd700$Te6edf3cTa5d6ff, expected current state Tffd700$Te6edf3fromStateTa5d6ff" Tb4b4b4}
Tff7b72returnTf0883e@withLock Tff7b72false
Tb4b4b4}
Tff7b72if Tb4b4b4(Te6edf3current Tff7b72=Tff7b72= Te6edf3cTb4b4b4) Tff7b72returnTf0883e@withLock Tff7b72false

T8b949e// If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting)
Tff7b72if Tb4b4b4(Te6edf3c Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3Connected Tff7b72&Tff7b72& Te6edf3current Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectingTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3d Tb4b4b4{ Ta5d6ff"Ta5d6ffIgnoring redundant transport connection signal while handshake is in progressTa5d6ff" Tb4b4b4}
Tff7b72returnTf0883e@withLock Tff7b72false
Tb4b4b4}

Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ffonConnectionChanged: Tffd700$Te6edf3currentTa5d6ff -> Tffd700$Te6edf3cTa5d6ff" Tb4b4b4}

Te6edf3sleepTimeoutTff7b72?.Te6edf3cancelTb4b4b4(Tb4b4b4)
Te6edf3sleepTimeout Tff7b72= Tff7b72null
Te6edf3preHandshakeJobTff7b72?.Te6edf3cancelTb4b4b4(Tb4b4b4)
Te6edf3preHandshakeJob Tff7b72= Tff7b72null
T8b949e// Collapse cancel+clear into one atomic swap so a concurrent re-arm cannot
T8b949e// orphan a job in the gap between cancel and reassign.
Te6edf3handshakeTimeoutTb4b4b4.Te6edf3getAndSetTb4b4b4(Tff7b72nullTb4b4b4)Tff7b72?.Te6edf3cancelTb4b4b4(Tb4b4b4)

Tff7b72when Tb4b4b4(Te6edf3cTb4b4b4) Tb4b4b4{
Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3Connecting Tff7b72-Tff7b72> Te6edf3serviceRepositoryTb4b4b4.Te6edf3setConnectionStateTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectingTb4b4b4)
Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3Connected Tff7b72-Tff7b72> Te6edf3handleConnectedTb4b4b4(Tb4b4b4)
Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3DeviceSleep Tff7b72-Tff7b72> Te6edf3handleDeviceSleepTb4b4b4(Tb4b4b4)
Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3Disconnected Tff7b72-Tff7b72> Te6edf3handleDisconnectedTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tff7b72true
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffhandleConnectedTb4b4b4(Tb4b4b4) Tb4b4b4{
T8b949e// Track whether this connection was restored from device sleep (vs. a fresh connect),
T8b949e// matching Apple's "connectionRestored" attribute for cross-platform DataDog parity.
Te6edf3connectionRestored Tff7b72= Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3DeviceSleep
T8b949e// The service state remains 'Connecting' until config is fully loaded
Tff7b72if Tb4b4b4(Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72!Tff7b72= Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectedTb4b4b4) Tb4b4b4{
Te6edf3serviceRepositoryTb4b4b4.Te6edf3setConnectionStateTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectingTb4b4b4)
Tb4b4b4}
Te6edf3connectTimeMsec Tff7b72= Te6edf3nowMillis
T8b949e// A fresh handshake is starting — clear the completion latch so progress signals during
T8b949e// this handshake can re-arm the fast watchdog. The latch is set by onHandshakeComplete()
T8b949e// and only matters in the window between Stage 2 completion and the subsequent Connected
T8b949e// transition; without this reset, a recovery-restarted handshake would have its progress
T8b949e// signals ignored (latch left set from the prior failed cycle). This covers both initial
T8b949e// user-initiated connects and transport-restart recovery siblings.
Te6edf3handshakeCompleteLatchTb4b4b4.Te6edf3value Tff7b72= Tff7b72false
Te6edf3lockdownCoordinatorTb4b4b4.Te6edf3onConnectTb4b4b4(Tb4b4b4)
T8b949e// Send a wake-up heartbeat before the config request. The firmware may be in a
T8b949e// power-saving state where the NimBLE callback context needs warming up. The 100ms
T8b949e// delay ensures the heartbeat BLE write is enqueued before the want_config_id
T8b949e// (sendToRadio is fire-and-forget through async coroutine launches).
Te6edf3preHandshakeJob Tff7b72=
Te6edf3scopeTb4b4b4.Te6edf3handledLaunch Tb4b4b4{
Te6edf3heartbeatSenderTb4b4b4.Te6edf3sendHeartbeatTb4b4b4(Ta5d6ff"Ta5d6ffpre-handshakeTa5d6ff"Tb4b4b4)
Te6edf3delayTb4b4b4(Te6edf3PRE_HANDSHAKE_SETTLE_MSTb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3i Tb4b4b4{ Ta5d6ff"Ta5d6ffStarting mesh handshake (Stage 1)Ta5d6ff" Tb4b4b4}
Te6edf3startConfigOnlyTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffstartHandshakeStallGuardTb4b4b4(Te6edf3stageTb4b4b4: Tffa657IntTb4b4b4, Te6edf3timeoutTb4b4b4: Te6edf3DurationTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3fastTransport Tff7b72= Te6edf3isFastRecoveryTransportTb4b4b4(Tb4b4b4)
T8b949e// On TCP/USB the firmware handshake completes in roughly 1s when healthy (logs show),
T8b949e// while a wedged socket takes the full ~30s transport read timeout without any further
T8b949e// progress. The aggressive 12s fast timeout recovers a stuck session quickly; BLE keeps
T8b949e// the original generous budget because its GATT latency is high and variable.
Tff7b72val Te6edf3effectiveTimeout Tff7b72= Tff7b72if Tb4b4b4(Te6edf3fastTransportTb4b4b4) Te6edf3FAST_HANDSHAKE_TIMEOUT Tff7b72else Te6edf3timeout
Tff7b72val Te6edf3transportLabel Tff7b72= Tff7b72if Tb4b4b4(Te6edf3fastTransportTb4b4b4) Ta5d6ff"Ta5d6fffast transportTa5d6ff" Tff7b72else Ta5d6ff"Ta5d6ffBLETa5d6ff"
T8b949e// Collapse cancel+reassign into one atomic swap so a concurrent re-arm cannot orphan a
T8b949e// job in the gap between cancel and reassign.
Te6edf3handshakeTimeout
Tb4b4b4.Te6edf3getAndSetTb4b4b4(
Te6edf3scopeTb4b4b4.Te6edf3handledLaunch Tb4b4b4{
Te6edf3delayTb4b4b4(Te6edf3effectiveTimeoutTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectingTb4b4b4) Tb4b4b4{
Tff7b72returnTf0883e@handledLaunch
Tb4b4b4}
T8b949e// A clean transport restart is the ONLY safe stall recovery on every transport.
T8b949e// The previous BLE branch re-sent want_config mid-session via action() here;
T8b949e// that re-send is now deliberately removed because firmware's handleStartConfig()
T8b949e// has no in-flight guard, so a second want_config on the same session re-enters
T8b949e// it and crashes the firmware (reproduced on T-Beam v2.7.25.104df5f in QA). The
T8b949e// firmware per-write dedup that was supposed to drop the retry is single-slot
T8b949e// (memcmp vs the previous write only), and interleaved heartbeats mean the re-sent
T8b949e// nonce is not byte-identical to the prior write — so it slips past dedup and
T8b949e// re-enters handleStartConfig(). A clean transport restart creates a fresh
T8b949e// session and re-enters the handshake naturally, which is both safer and more
T8b949e// deterministic than a same-session retry.
Te6edf3LoggerTb4b4b4.Te6edf3e Tb4b4b4{
Ta5d6ff"Ta5d6ffHandshake stall detected at Stage Tffd700$Te6edf3stageTa5d6ff on Tffd700$Te6edf3transportLabelTa5d6ff — Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6ffrequesting forced transport restartTa5d6ff"
Tb4b4b4}
Te6edf3runSiblingHandshakeRecoveryTb4b4b4(Tb4b4b4)
Tb4b4b4}Tb4b4b4,
Tb4b4b4)
Tff7b72?.Te6edf3cancelTb4b4b4(Tb4b4b4)
Tb4b4b4}

T8b949e/**
* Launches the deterministic two-phase stall-recovery sibling used by [startHandshakeStallGuard] on every
* transport.
*
* Phase 1 flips the app-level state from Connecting to Disconnected first, guarded by the connection mutex so a
* just-completed handshake cannot be torn down after winning the race. Phase 2 then calls
* [RadioInterfaceService.restartTransport], whose emissions (DeviceSleep → Connected) now arrive from app-level
* Disconnected, bypass the redundant-Connecting guard in [onConnectionChanged], and re-enter [handleConnected] to
* restart the handshake cleanly.
*
* We MUST NOT call [onConnectionChanged] from the [handshakeTimeout] coroutine after launching the sibling:
* [onConnectionChanged] cancels handshakeTimeout (the very job running this code), and any work chained after the
* launch is not guaranteed to run. We MUST ALSO NOT leave the explicit Disconnected call in this coroutine after
* the sibling launch — otherwise the sibling's restart emissions (DeviceSleep, then Connected) can arrive while the
* app-level state is still Connecting, causing [onConnectionChanged]'s redundant-Connected-while-Connecting guard
* to ignore the fresh Connected emission. That leaves the app Disconnected while transport is Connected — the same
* split-brain this restart path is meant to break.
*
* By the time the sibling runs, handshakeTimeout has already completed naturally (it launched the sibling and
* returned), so the cancellation [onConnectionChanged] would attempt is a no-op on an already-completed job — and
* because the sibling is parented to `scope`, not to handshakeTimeout, it survives independently.
*
* Concurrent-recovery protection is layered across three independent mechanisms, each sufficient on its own:
* 1. The [connectionMutex] serializes the [onConnectionChanged]`(Disconnected, fromState=Connecting)` call inside
* the sibling. If a concurrent sibling already transitioned the app state to Disconnected, a second caller's
* `fromState=Connecting` precondition fails and [onConnectionChanged] returns `false`, so the `if (disconnected
* && ...)` gate skips `restartTransport()`.
* 2. The transport-level `isRestarting` CAS inside `SharedRadioInterfaceService.restartTransport()` provides
* authoritative dedup at the layer that actually tears down and re-creates the transport, independent of the
* app-level state machine above it.
* 3. The atomic [consecutiveRecoveryFailures]`getAndIncrement()` ensures each concurrent caller observes a unique
* `priorFailures` slot, so the give-up decision (and the backoff duration) is race-free even if two siblings
* race the same stall window.
*
* Backoff and give-up cap: each call atomically increments [consecutiveRecoveryFailures]. Prior failures trigger an
* exponential [delay] (2 s base, doubling each failure, capped at 30 s) before the transport restart so a node that
* keeps crashing under handshake re-entry is not re-driven in a tight loop. After
* [MAX_CONSECUTIVE_RECOVERY_FAILURES] consecutive failed recoveries, recovery stops and a sticky user-facing error
* is surfaced (Disconnected + error message) instead of silently retrying indefinitely; the user must manually
* re-select the node to retry, which resets the streak. The streak is also reset to 0 by [onHandshakeComplete] (a
* successful handshake).
*/
Tff7b72private Tff7b72fun Td2a8ffrunSiblingHandshakeRecoveryTb4b4b4(Tb4b4b4) Tb4b4b4{
T8b949e// Atomically claim a failure slot. getAndIncrement guarantees each concurrent caller
T8b949e// observes a unique priorFailures value, so the give-up decision is race-free.
Tff7b72val Te6edf3priorFailures Tff7b72= Te6edf3consecutiveRecoveryFailuresTb4b4b4.Te6edf3getAndIncrementTb4b4b4(Tb4b4b4)
Te6edf3scopeTb4b4b4.Te6edf3handledLaunch Tb4b4b4{
T8b949e// After MAX_CONSECUTIVE_RECOVERY_FAILURES consecutive failed recoveries, stop retrying
T8b949e// and surface a sticky error. A node that keeps failing the handshake is likely
T8b949e// crashing firmware under re-entry (reproduced on T-Beam v2.7.25.104df5f), and
T8b949e// re-driving it indefinitely only makes things worse. Reset the counter here so a
T8b949e// manual retry from the user starts a fresh streak.
Tff7b72if Tb4b4b4(Te6edf3priorFailures Tff7b72>Tff7b72= Te6edf3MAX_CONSECUTIVE_RECOVERY_FAILURESTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3e Tb4b4b4{
Ta5d6ff"Ta5d6ffHandshake recovery exhausted after Tffd700$Te6edf3MAX_CONSECUTIVE_RECOVERY_FAILURESTa5d6ff consecutive Ta5d6ff" Tff7b72+
Ta5d6ff"Ta5d6fffailures; surfacing sticky errorTa5d6ff"
Tb4b4b4}
Te6edf3consecutiveRecoveryFailuresTb4b4b4.Te6edf3value Tff7b72= T79c0ff0
Te6edf3serviceRepositoryTb4b4b4.Te6edf3setConnectionProgressTb4b4b4(Ta5d6ff"Ta5d6ff"Tb4b4b4)
Te6edf3onConnectionChangedTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3DisconnectedTb4b4b4, Te6edf3fromState Tff7b72= Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectingTb4b4b4)
T8b949e// safeCatchingAll swallows Skiko ExceptionInInitializerError on headless JVM tests
T8b949e// where compose-resources can't load native libs. Production resolves the localized
T8b949e// string normally; tests fall back to empty and setErrorMessage is still called.
Tff7b72val Te6edf3errorMessage Tff7b72=
Te6edf3safeCatchingAll Tb4b4b4{ Te6edf3getStringSuspendTb4b4b4(Te6edf3ResTb4b4b4.Te6edf3stringTb4b4b4.Te6edf3error_recovery_exhaustedTb4b4b4) Tb4b4b4}Tb4b4b4.Te6edf3getOrDefaultTb4b4b4(Ta5d6ff"Ta5d6ff"Tb4b4b4)
Te6edf3serviceRepositoryTb4b4b4.Te6edf3setErrorMessageTb4b4b4(Te6edf3errorMessageTb4b4b4, Te6edf3SeverityTb4b4b4.Te6edf3ErrorTb4b4b4)
Tff7b72returnTf0883e@handledLaunch
Tb4b4b4}
T8b949e// Exponential backoff before the next attempt: 2 s base, doubling for each prior
T8b949e// failure, hard-capped at 30 s. Skipped on the first attempt (no prior failures).
T8b949e// The delay is parented to `scope`, so it is cancelled cleanly if the user
T8b949e// disconnects, navigates away, or the service shuts down (structured concurrency).
Tff7b72if Tb4b4b4(Te6edf3priorFailures Tff7b72> T79c0ff0Tb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3backoffSeconds Tff7b72=
Tb4b4b4(Te6edf3RECOVERY_BACKOFF_BASE_SECONDS Te6edf3shl Tb4b4b4(Te6edf3priorFailures Tff7b72- T79c0ff1Tb4b4b4)Tb4b4b4)Tb4b4b4.Te6edf3coerceAtMostTb4b4b4(Te6edf3RECOVERY_BACKOFF_CAP_SECONDSTb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{
Ta5d6ff"Ta5d6ffHandshake recovery backoff: waiting Tffd700${Te6edf3backoffSecondsTffd700}Ta5d6ffs before retry (attempt Tffd700${Te6edf3priorFailures Tff7b72+ T79c0ff1Tffd700}Ta5d6ff)Ta5d6ff"
Tb4b4b4}
Te6edf3delayTb4b4b4(Te6edf3backoffSecondsTb4b4b4.Te6edf3secondsTb4b4b4)
Tb4b4b4}
T8b949e// Re-check state after backoff: the user may have disconnected during the delay.
Tff7b72if Tb4b4b4(Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectingTb4b4b4) Tff7b72returnTf0883e@handledLaunch
T8b949e// Surface the forced-recovery progress to the UI before the app-level Disconnected
T8b949e// transition lands, so the user sees "Reconnecting…" rather than a stale
T8b949e// "Loading node list" while the transport is being torn down and re-established.
T8b949e//
T8b949e// This progress is intentionally NOT cleared on the recovery's Disconnected window
T8b949e// (i.e. NOT in handleDisconnected or this sibling). If recovery fails permanently,
T8b949e// "Reconnecting…" may persist on the Disconnected screen until the user retries or
T8b949e// navigates away. That leak is acceptable UX: the transport restart has been
T8b949e// requested and may still be in flight (restartTransport is one-shot — if the fresh
T8b949e// transport also fails, nothing here retries automatically; transport-level
T8b949e// network-recovery listeners may independently re-bring-up the transport, and the
T8b949e// user can manually retry). Clearing it here would race the deliberate UX signal:
T8b949e// handleDisconnected runs synchronously after this call inside the same
T8b949e// onConnectionChanged transition, so any clear there would clobber the signal before
T8b949e// restartTransport runs. Stale progress is instead cleared at the first downstream
T8b949e// setConnectionProgress call during the recovery handshake (e.g. "Device config
T8b949e// received" or "Loading node list"), not by handleConnected itself — and at the
T8b949e// ViewModel level the Connecting state already dominates progress (the CONNECTING
T8b949e// status ignores the progress string), so the UI is correct regardless.
Te6edf3serviceRepositoryTb4b4b4.Te6edf3setConnectionProgressTb4b4b4(Te6edf3ServiceRepositoryTb4b4b4.Te6edf3RECONNECTING_PROGRESS_TEXTTb4b4b4)
Tff7b72val Te6edf3disconnected Tff7b72= Te6edf3onConnectionChangedTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3DisconnectedTb4b4b4, Te6edf3fromState Tff7b72= Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectingTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3disconnected Tff7b72&Tff7b72& Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3DisconnectedTb4b4b4) Tb4b4b4{
Te6edf3radioInterfaceServiceTb4b4b4.Te6edf3restartTransportTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Tff7b72override Tff7b72fun Td2a8ffrecoverPostHandshakeFailureTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{ Ta5d6ff"Ta5d6ffRecovering from post-handshake failure by restarting transportTa5d6ff" Tb4b4b4}
Te6edf3runSiblingHandshakeRecoveryTb4b4b4(Tb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8fftearDownConnectionTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3packetHandlerTb4b4b4.Te6edf3stopPacketQueueTb4b4b4(Tb4b4b4)
Te6edf3sessionManagerTb4b4b4.Te6edf3clearAllTb4b4b4(Tb4b4b4) T8b949e// Prevent stale per-node passkeys on reconnect.
Te6edf3locationManagerTb4b4b4.Te6edf3stopTb4b4b4(Tb4b4b4)
Te6edf3mqttManagerTb4b4b4.Te6edf3stopTb4b4b4(Tb4b4b4)
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffhandleDeviceSleepTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3serviceRepositoryTb4b4b4.Te6edf3setConnectionStateTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3DeviceSleepTb4b4b4)
Te6edf3tearDownConnectionTb4b4b4(Tb4b4b4)

Tff7b72if Tb4b4b4(Te6edf3connectTimeMsec Tff7b72!Tff7b72= T79c0ff0LTb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3now Tff7b72= Te6edf3nowMillis
Tff7b72val Te6edf3duration Tff7b72= Te6edf3now Tff7b72- Te6edf3connectTimeMsec
Te6edf3connectTimeMsec Tff7b72= T79c0ff0L
Te6edf3analyticsTb4b4b4.Te6edf3trackTb4b4b4(
Te6edf3EVENT_CONNECTED_SECONDSTb4b4b4,
Te6edf3DataPairTb4b4b4(Te6edf3EVENT_CONNECTED_SECONDSTb4b4b4, Te6edf3durationTb4b4b4.Te6edf3millisecondsTb4b4b4.Te6edf3toDoubleTb4b4b4(Te6edf3DurationUnitTb4b4b4.Te6edf3SECONDSTb4b4b4)Tb4b4b4)Tb4b4b4,
Tb4b4b4)
Tb4b4b4}

Te6edf3sleepTimeout Tff7b72=
Te6edf3scopeTb4b4b4.Te6edf3handledLaunch Tb4b4b4{
Tff7b72try Tb4b4b4{
Tff7b72val Te6edf3localConfig Tff7b72= Te6edf3radioConfigRepositoryTb4b4b4.Te6edf3localConfigFlowTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3rawTimeout Tff7b72= Tb4b4b4(Te6edf3localConfigTb4b4b4.Te6edf3powerTff7b72?.Te6edf3ls_secs Tff7b72?: T79c0ff0Tb4b4b4) Tff7b72+ Te6edf3DEVICE_SLEEP_TIMEOUT_SECONDS
T8b949e// Cap the timeout so routers or power-saving configs (ls_secs=3600) don't
T8b949e// leave the UI stuck in DeviceSleep for over an hour.
Tff7b72val Te6edf3timeout Tff7b72= Te6edf3rawTimeoutTb4b4b4.Te6edf3coerceAtMostTb4b4b4(Te6edf3MAX_SLEEP_TIMEOUT_SECONDSTb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3d Tb4b4b4{ Ta5d6ff"Ta5d6ffWaiting for sleeping device, timeout=Tffd700$Te6edf3timeoutTa5d6ff secs (raw=Tffd700$Te6edf3rawTimeoutTa5d6ff)Ta5d6ff" Tb4b4b4}
Te6edf3delayTb4b4b4(Te6edf3timeoutTb4b4b4.Te6edf3secondsTb4b4b4)
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{ Ta5d6ff"Ta5d6ffDevice timed out, setting disconnectedTa5d6ff" Tb4b4b4}
Te6edf3onConnectionChangedTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3DisconnectedTb4b4b4)
Tb4b4b4} Tff7b72catch Tb4b4b4(Te6edf3_Tb4b4b4: Te6edf3CancellationExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3d Tb4b4b4{ Ta5d6ff"Ta5d6ffdevice sleep timeout cancelledTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffhandleDisconnectedTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3serviceRepositoryTb4b4b4.Te6edf3setConnectionStateTb4b4b4(Te6edf3ConnectionStateTb4b4b4.Te6edf3DisconnectedTb4b4b4)
Te6edf3lockdownCoordinatorTb4b4b4.Te6edf3onDisconnectTb4b4b4(Tb4b4b4)
Te6edf3tearDownConnectionTb4b4b4(Tb4b4b4)

Te6edf3analyticsTb4b4b4.Te6edf3trackTb4b4b4(
Te6edf3EVENT_MESH_DISCONNECTTb4b4b4,
Te6edf3DataPairTb4b4b4(Te6edf3KEY_NUM_NODESTb4b4b4, Te6edf3nodeManagerTb4b4b4.Te6edf3nodeDBbyNodeNumTb4b4b4.Te6edf3sizeTb4b4b4)Tb4b4b4,
Te6edf3DataPairTb4b4b4(Te6edf3KEY_NUM_ONLINETb4b4b4, Te6edf3nodeManagerTb4b4b4.Te6edf3nodeDBbyNodeNumTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3count Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3isOnline Tb4b4b4}Tb4b4b4)Tb4b4b4,
Tb4b4b4)
Te6edf3analyticsTb4b4b4.Te6edf3trackTb4b4b4(Te6edf3EVENT_NUM_NODESTb4b4b4, Te6edf3DataPairTb4b4b4(Te6edf3KEY_NUM_NODESTb4b4b4, Te6edf3nodeManagerTb4b4b4.Te6edf3nodeDBbyNodeNumTb4b4b4.Te6edf3sizeTb4b4b4)Tb4b4b4)
Tb4b4b4}

Tff7b72override Tff7b72fun Td2a8ffstartConfigOnlyTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3startHandshakeStallGuardTb4b4b4(T79c0ff1Tb4b4b4, Te6edf3HANDSHAKE_TIMEOUT_STAGE1Tb4b4b4)
Te6edf3packetHandlerTb4b4b4.Te6edf3sendToRadioTb4b4b4(Te6edf3ToRadioTb4b4b4(Te6edf3want_config_id Tff7b72= Te6edf3HandshakeConstantsTb4b4b4.Te6edf3CONFIG_NONCETb4b4b4)Tb4b4b4)
Tb4b4b4}

Tff7b72override Tff7b72fun Td2a8ffclearRadioConfigTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3scopeTb4b4b4.Te6edf3handledLaunch Tb4b4b4{
Te6edf3radioConfigRepositoryTb4b4b4.Te6edf3clearLocalConfigTb4b4b4(Tb4b4b4)
Te6edf3radioConfigRepositoryTb4b4b4.Te6edf3clearChannelSetTb4b4b4(Tb4b4b4)
Te6edf3radioConfigRepositoryTb4b4b4.Te6edf3clearLocalModuleConfigTb4b4b4(Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

Tff7b72override Tff7b72fun Td2a8ffstartNodeInfoOnlyTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3startHandshakeStallGuardTb4b4b4(T79c0ff2Tb4b4b4, Te6edf3HANDSHAKE_TIMEOUT_STAGE2Tb4b4b4)
Te6edf3packetHandlerTb4b4b4.Te6edf3sendToRadioTb4b4b4(Te6edf3ToRadioTb4b4b4(Te6edf3want_config_id Tff7b72= Te6edf3HandshakeConstantsTb4b4b4.Te6edf3NODE_INFO_NONCETb4b4b4)Tb4b4b4)
Tb4b4b4}

Tff7b72override Tff7b72fun Td2a8ffonRadioConfigLoadedTb4b4b4(Tb4b4b4) Tb4b4b4{
Te6edf3scopeTb4b4b4.Te6edf3handledLaunch Tb4b4b4{
Tff7b72val Te6edf3queuedPackets Tff7b72= Te6edf3packetRepositoryTb4b4b4.Te6edf3getQueuedPacketsTb4b4b4(Tb4b4b4)
Te6edf3queuedPacketsTb4b4b4.Te6edf3forEach Tb4b4b4{ Te6edf3packet Tff7b72-Tff7b72>
Tff7b72try Tb4b4b4{
Te6edf3workerManagerTb4b4b4.Te6edf3enqueueSendMessageTb4b4b4(Te6edf3packetTb4b4b4.Te6edf3idTb4b4b4)
Tb4b4b4} Tff7b72catch Tb4b4b4(Tf0883e@SuppressTb4b4b4(Ta5d6ff"Ta5d6ffTooGenericExceptionCaughtTa5d6ff"Tb4b4b4) Te6edf3eTb4b4b4: Te6edf3ExceptionTb4b4b4) Tb4b4b4{
Te6edf3LoggerTb4b4b4.Te6edf3eTb4b4b4(Te6edf3eTb4b4b4) Tb4b4b4{ Ta5d6ff"Ta5d6ffFailed to enqueue queued packet workerTa5d6ff" Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}
Tb4b4b4}

Tff7b72override Tff7b72suspend Tff7b72fun Td2a8ffonNodeDbReadyTb4b4b4(Tb4b4b4) Tb4b4b4{
T8b949e// Collapse cancel+clear into one atomic swap so a concurrent re-arm cannot
T8b949e// orphan a job in the gap between cancel and reassign.
Te6edf3handshakeTimeoutTb4b4b4.Te6edf3getAndSetTb4b4b4(Tff7b72nullTb4b4b4)Tff7b72?.Te6edf3cancelTb4b4b4(Tb4b4b4)

Tff7b72val Te6edf3myNodeNum Tff7b72= Te6edf3nodeManagerTb4b4b4.Te6edf3myNodeNumTb4b4b4.Te6edf3value Tff7b72?: T79c0ff0
T8b949e// Set device time now that the full node picture is ready. Sending this during Stage 1
T8b949e// (onRadioConfigLoaded) introduced GATT write contention with the Stage 2 node-info burst.
Te6edf3commandSenderTb4b4b4.Te6edf3sendAdminTb4b4b4(Te6edf3myNodeNumTb4b4b4) Tb4b4b4{ Te6edf3AdminMessageTb4b4b4(Te6edf3set_time_only Tff7b72= Te6edf3nowSecondsTb4b4b4.Te6edf3toIntTb4b4b4(Tb4b4b4)Tb4b4b4) Tb4b4b4}

T8b949e// Proactively seed the session passkey. The firmware embeds session_passkey in every
T8b949e// admin *response* (wantResponse=true), but set_time_only has no response. A get_owner
T8b949e// request is the lightest way to trigger a response and populate the passkey cache so
T8b949e// that subsequent write operations don't fail with ADMIN_BAD_SESSION_KEY.
Te6edf3commandSenderTb4b4b4.Te6edf3sendAdminTb4b4b4(Te6edf3myNodeNumTb4b4b4, Te6edf3wantResponse Tff7b72= Tff7b72trueTb4b4b4) Tb4b4b4{ Te6edf3AdminMessageTb4b4b4(Te6edf3get_owner_request Tff7b72= Tff7b72trueTb4b4b4) Tb4b4b4}

T8b949e// Start MQTT if enabled
Te6edf3scopeTb4b4b4.Te6edf3handledLaunch Tb4b4b4{
Tff7b72val Te6edf3moduleConfig Tff7b72= Te6edf3radioConfigRepositoryTb4b4b4.Te6edf3moduleConfigFlowTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)
Te6edf3mqttManagerTb4b4b4.Te6edf3startProxyTb4b4b4(
Te6edf3moduleConfigTb4b4b4.Te6edf3mqttTff7b72?.Te6edf3enabled Tff7b72=Tff7b72= Tff7b72trueTb4b4b4,
Te6edf3moduleConfigTb4b4b4.Te6edf3mqttTff7b72?.Te6edf3proxy_to_client_enabled Tff7b72=Tff7b72= Tff7b72trueTb4b4b4,
Tb4b4b4)
Tb4b4b4}

Te6edf3reportConnectionTb4b4b4(Tb4b4b4)

T8b949e// Request history
Te6edf3scopeTb4b4b4.Te6edf3handledLaunch Tb4b4b4{
Tff7b72val Te6edf3moduleConfig Tff7b72= Te6edf3radioConfigRepositoryTb4b4b4.Te6edf3moduleConfigFlowTb4b4b4.Te6edf3firstTb4b4b4(Tb4b4b4)
Te6edf3moduleConfigTb4b4b4.Te6edf3store_forwardTff7b72?.Te6edf3let Tb4b4b4{
Te6edf3historyManagerTb4b4b4.Te6edf3requestHistoryReplayTb4b4b4(Ta5d6ff"Ta5d6ffonNodeDbReadyTa5d6ff"Tb4b4b4, Te6edf3myNodeNumTb4b4b4, Tffa657itTb4b4b4, Ta5d6ff"Ta5d6ffUnknownTa5d6ff"Tb4b4b4)
Tb4b4b4}
Tb4b4b4}

T8b949e// Request immediate LocalStats and DeviceMetrics update on connection with proper request IDs
Te6edf3commandSenderTb4b4b4.Te6edf3requestTelemetryTb4b4b4(Te6edf3commandSenderTb4b4b4.Te6edf3generatePacketIdTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3myNodeNumTb4b4b4, Te6edf3TelemetryTypeTb4b4b4.Te6edf3LOCAL_STATSTb4b4b4.Te6edf3ordinalTb4b4b4)
Te6edf3commandSenderTb4b4b4.Te6edf3requestTelemetryTb4b4b4(Te6edf3commandSenderTb4b4b4.Te6edf3generatePacketIdTb4b4b4(Tb4b4b4)Tb4b4b4, Te6edf3myNodeNumTb4b4b4, Te6edf3TelemetryTypeTb4b4b4.Te6edf3DEVICETb4b4b4.Te6edf3ordinalTb4b4b4)
Tb4b4b4}

T8b949e/**
* Synchronously cancels the transport-aware handshake watchdog the moment Stage 2 completes (NODE_INFO_NONCE
* received). Does NOT replicate [onNodeDbReady]'s post-NodeDB side effects (analytics, MQTT start, history replay,
* telemetry requests) — those remain gated on [onNodeDbReady] at the end of the async DB install block.
*
* See [MeshConnectionManager.onHandshakeComplete] for the full rationale.
*/
Tff7b72override Tff7b72fun Td2a8ffonHandshakeCompleteTb4b4b4(Tb4b4b4) Tb4b4b4{
T8b949e// Collapse cancel+clear into one atomic swap so a concurrent re-arm cannot orphan a
T8b949e// job in the gap between cancel and reassign.
Te6edf3handshakeTimeoutTb4b4b4.Te6edf3getAndSetTb4b4b4(Tff7b72nullTb4b4b4)Tff7b72?.Te6edf3cancelTb4b4b4(Tb4b4b4)
T8b949e// Set the completion latch so late-arriving progress packets (e.g. FileInfo that lands
T8b949e// between NODE_INFO_NONCE and the async Room DB install completion) cannot re-arm the
T8b949e// fast watchdog we just cancelled. Without this latch, those late packets would
T8b949e// re-introduce the slow-DB false-trip on large meshes that this method was added to
T8b949e// prevent — the app state is still Connecting during that window, so the Connecting-
T8b949e// state guard inside onHandshakeProgress() is insufficient on its own. The latch is
T8b949e// cleared at the start of the next fresh handshake in handleConnected().
Te6edf3handshakeCompleteLatchTb4b4b4.Te6edf3value Tff7b72= Tff7b72true
T8b949e// A successful handshake breaks the recovery failure streak — reset the consecutive
T8b949e// failure counter so the next stall starts from a fresh count.
Te6edf3consecutiveRecoveryFailuresTb4b4b4.Te6edf3value Tff7b72= T79c0ff0
Tb4b4b4}

Tff7b72private Tff7b72fun Td2a8ffreportConnectionTb4b4b4(Tb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3myNode Tff7b72= Te6edf3nodeManagerTb4b4b4.Te6edf3getMyNodeInfoTb4b4b4(Tb4b4b4)
Tff7b72val Te6edf3radioModel Tff7b72= Te6edf3DataPairTb4b4b4(Te6edf3KEY_RADIO_MODELTb4b4b4, Te6edf3myNodeTff7b72?.Te6edf3model Tff7b72?: Ta5d6ff"Ta5d6ffunknownTa5d6ff"Tb4b4b4)
Te6edf3analyticsTb4b4b4.Te6edf3trackTb4b4b4(
Te6edf3EVENT_MESH_CONNECTTb4b4b4,
Te6edf3DataPairTb4b4b4(Te6edf3KEY_NUM_NODESTb4b4b4, Te6edf3nodeManagerTb4b4b4.Te6edf3nodeDBbyNodeNumTb4b4b4.Te6edf3sizeTb4b4b4)Tb4b4b4,
Te6edf3DataPairTb4b4b4(Te6edf3KEY_NUM_ONLINETb4b4b4, Te6edf3nodeManagerTb4b4b4.Te6edf3nodeDBbyNodeNumTb4b4b4.Te6edf3valuesTb4b4b4.Te6edf3count Tb4b4b4{ Tffa657itTb4b4b4.Te6edf3isOnline Tb4b4b4}Tb4b4b4)Tb4b4b4,
Te6edf3radioModelTb4b4b4,
Tb4b4b4)

T8b949e// DataDog RUM custom action matching Apple's "connect" event for cross-platform analytics.
Tff7b72val Te6edf3transportType Tff7b72= Te6edf3radioInterfaceServiceTb4b4b4.Te6edf3getDeviceAddressTb4b4b4(Tb4b4b4)Tff7b72?.Te6edf3let Tb4b4b4{ Te6edf3DeviceTypeTb4b4b4.Te6edf3fromAddressTb4b4b4(Tffa657itTb4b4b4)Tff7b72?.Te6edf3name Tb4b4b4}
Te6edf3analyticsTb4b4b4.Te6edf3trackConnectTb4b4b4(
Te6edf3firmwareVersion Tff7b72= Te6edf3myNodeTff7b72?.Te6edf3firmwareVersionTb4b4b4,
Te6edf3transportType Tff7b72= Te6edf3transportTypeTb4b4b4,
Te6edf3hardwareModel Tff7b72= Te6edf3myNodeTff7b72?.Te6edf3modelTb4b4b4,
Te6edf3nodes Tff7b72= Te6edf3nodeManagerTb4b4b4.Te6edf3nodeDBbyNodeNumTb4b4b4.Te6edf3sizeTb4b4b4,
Te6edf3connectionRestored Tff7b72= Te6edf3connectionRestoredTb4b4b4,
Tb4b4b4)
Tb4b4b4}

Tff7b72override Tff7b72fun Td2a8ffupdateTelemetryTb4b4b4(Te6edf3tTb4b4b4: Te6edf3TelemetryTb4b4b4) Tb4b4b4{
Te6edf3tTb4b4b4.Te6edf3local_statsTff7b72?.Te6edf3let Tb4b4b4{ Te6edf3nodeRepositoryTb4b4b4.Te6edf3updateLocalStatsTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4}
Te6edf3updateStatusNotificationTb4b4b4(Te6edf3tTb4b4b4)
Tb4b4b4}

T8b949e/**
* True when the active transport is a TCP or USB serial connection — i.e. a transport whose firmware handshake
* reliably completes in roughly 1s when healthy and therefore benefits from aggressive silent-restart on stall.
* Uses the same [DeviceType.fromAddress] pattern as [reportConnection] for transport classification. BLE is
* excluded because its GATT latency budget is high and variable enough that the long-and-retry stall-guard budgets
* remain the right trade-off.
*/
Tff7b72private Tff7b72fun Td2a8ffisFastRecoveryTransportTb4b4b4(Tb4b4b4)Tb4b4b4: Tffa657Boolean Tff7b72=
Te6edf3radioInterfaceServiceTb4b4b4.Te6edf3getDeviceAddressTb4b4b4(Tb4b4b4)Tff7b72?.Te6edf3let Tb4b4b4{ Te6edf3DeviceTypeTb4b4b4.Te6edf3fromAddressTb4b4b4(Tffa657itTb4b4b4) Tb4b4b4} Tff7b72in Te6edf3FAST_RECOVERY_TYPES

Tff7b72override Tff7b72fun Td2a8ffonHandshakeProgressTb4b4b4(Tb4b4b4) Tb4b4b4{
T8b949e// Arm only inside the fast-recovery envelope, while Connecting, before the completion latch
T8b949e// has fired. BLE retains the long stall-guard budget because its GATT latency is variable.
Tff7b72val Te6edf3shouldArmFastWatchdog Tff7b72=
Te6edf3isFastRecoveryTransportTb4b4b4(Tb4b4b4) Tff7b72&Tff7b72&
Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72is Te6edf3ConnectionStateTb4b4b4.Te6edf3Connecting Tff7b72&Tff7b72&
Tff7b72!Te6edf3handshakeCompleteLatchTb4b4b4.Te6edf3value
Tff7b72if Tb4b4b4(Tff7b72!Te6edf3shouldArmFastWatchdogTb4b4b4) Tff7b72return
T8b949e// Atomic swap: cancel any in-flight fast watchdog and re-arm it with the full fast
T8b949e// timeout in a single operation. This keeps the watchdog quiet as long as meaningful
T8b949e// progress keeps arriving within the window, while a true stall still fires on
T8b949e// schedule. getAndSet prevents a concurrent re-arm from orphaning a job in the gap
T8b949e// between cancel and reassign.
Te6edf3handshakeTimeout
Tb4b4b4.Te6edf3getAndSetTb4b4b4(
Te6edf3scopeTb4b4b4.Te6edf3handledLaunch Tb4b4b4{
Te6edf3delayTb4b4b4(Te6edf3FAST_HANDSHAKE_TIMEOUTTb4b4b4)
Tff7b72if Tb4b4b4(Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value Tff7b72!is Te6edf3ConnectionStateTb4b4b4.Te6edf3ConnectingTb4b4b4) Tb4b4b4{
Tff7b72returnTf0883e@handledLaunch
Tb4b4b4}
T8b949e// Warn, not error: the watchdog firing is the recovery mechanism working, and the cause is a
T8b949e// stalled radio or link rather than a defect here. A throwable-less Logger.e still synthesises a
T8b949e// non-fatal in Crashlytics and a RUM error, which made this one of the loudest issues in triage.
T8b949e// Track it as a rate over this log line instead.
Te6edf3LoggerTb4b4b4.Te6edf3w Tb4b4b4{
Ta5d6ff"Ta5d6ffFast-handshake watchdog expired after progress stalled — requesting forced transport restartTa5d6ff"
Tb4b4b4}
Te6edf3runSiblingHandshakeRecoveryTb4b4b4(Tb4b4b4)
Tb4b4b4}Tb4b4b4,
Tb4b4b4)
Tff7b72?.Te6edf3cancelTb4b4b4(Tb4b4b4)
Tb4b4b4}

Tff7b72override Tff7b72fun Td2a8ffupdateStatusNotificationTb4b4b4(Te6edf3telemetryTb4b4b4: Te6edf3Telemetry?Tb4b4b4) Tb4b4b4{
Tff7b72val Te6edf3state Tff7b72= Te6edf3serviceRepositoryTb4b4b4.Te6edf3connectionStateTb4b4b4.Te6edf3value
T8b949e// During an expected node restart the disconnect is transient; keep the persistent notification on the
T8b949e// connecting presentation instead of flashing "disconnected".
Tff7b72val Te6edf3presented Tff7b72=
Tff7b72if Tb4b4b4(Te6edf3state Tff7b72=Tff7b72= Te6edf3ConnectionStateTb4b4b4.Te6edf3Disconnected Tff7b72&Tff7b72& Te6edf3nodeRestartTrackerTb4b4b4.Te6edf3restartExpectedTb4b4b4.Te6edf3valueTb4b4b4) Tb4b4b4{
Te6edf3ConnectionStateTb4b4b4.Te6edf3Connecting
Tb4b4b4} Tff7b72else Tb4b4b4{
Te6edf3state
Tb4b4b4}
Te6edf3serviceNotificationsTb4b4b4.Te6edf3updateServiceStateNotificationTb4b4b4(Te6edf3presentedTb4b4b4, Te6edf3telemetry Tff7b72= Te6edf3telemetryTb4b4b4)
Tb4b4b4}

Tff7b72companion Tff7b72object Tb4b4b4{
T8b949e// Hoisted constant — used on every meaningful handshake packet via
T8b949e// isFastRecoveryTransport(); avoids allocating a fresh Set per packet.
Tff7b72private Tff7b72val Te6edf3FAST_RECOVERY_TYPES Tff7b72= Te6edf3setOfTb4b4b4(Te6edf3DeviceTypeTb4b4b4.Te6edf3TCPTb4b4b4, Te6edf3DeviceTypeTb4b4b4.Te6edf3USBTb4b4b4)

Tff7b72private Tff7b72const Tff7b72val Te6edf3DEVICE_SLEEP_TIMEOUT_SECONDS Tff7b72= T79c0ff3T79c0ff0

T8b949e// Maximum time (in seconds) to wait for a sleeping device before declaring it
T8b949e// disconnected, regardless of the device's ls_secs configuration. Without this
T8b949e// cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour.
Tff7b72private Tff7b72const Tff7b72val Te6edf3MAX_SLEEP_TIMEOUT_SECONDS Tff7b72= T79c0ff3T79c0ff0T79c0ff0

T8b949e/**
* Delay between the pre-handshake heartbeat and the want_config_id send.
*
* Ensures the heartbeat BLE write completes and the firmware's NimBLE callback context is warmed up before the
* config request arrives. 100ms is well above observed ESP32 task scheduling latency (~10–50ms) while adding
* negligible connection latency.
*/
Tff7b72private Tff7b72const Tff7b72val Te6edf3PRE_HANDSHAKE_SETTLE_MS Tff7b72= T79c0ff1T79c0ff0T79c0ff0L

Tff7b72private Tff7b72val Te6edf3HANDSHAKE_TIMEOUT_STAGE1 Tff7b72= T79c0ff3T79c0ff0.Te6edf3seconds

T8b949e/**
* Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes.
* 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+
* nodes.
*/
Tff7b72private Tff7b72val Te6edf3HANDSHAKE_TIMEOUT_STAGE2 Tff7b72= T79c0ff6T79c0ff0.Te6edf3seconds

T8b949e/**
* Transport-aware fast-recovery timeout for the handshake stall guard, applied only to TCP and USB serial
* transports.
*
* Production logs on TCP/USB show a healthy firmware handshake completes in roughly 1 second, while a wedged
* socket sits idle for the full transport-level read timeout (~30s) without any further progress. 12s sits
* comfortably above the healthy success envelope and well below the transport read timeout, so firing a silent
* [RadioInterfaceService.restartTransport] at 12s recovers a stuck TCP/USB session quickly without
* false-positiving on healthy connections.
*
* BLE is intentionally excluded — its GATT latency budget is variable enough that the existing
* [HANDSHAKE_TIMEOUT_STAGE1] (30s) and [HANDSHAKE_TIMEOUT_STAGE2] (60s) budgets remain the right trade-off.
* Both transports now recover exclusively via [runSiblingHandshakeRecovery] (transport restart); the previous
* BLE mid-session want_config retry has been removed (see [startHandshakeStallGuard] for the firmware crash
* rationale).
*/
Tff7b72private Tff7b72val Te6edf3FAST_HANDSHAKE_TIMEOUT Tff7b72= T79c0ff1T79c0ff2.Te6edf3seconds

T8b949e/**
* Maximum consecutive handshake-recovery attempts before surfacing a sticky error to the user.
*
* Each call to [runSiblingHandshakeRecovery] counts as one attempt. After this many consecutive attempts fail
* to lead to a successful handshake, recovery stops and a sticky Disconnected + error state is surfaced,
* requiring the user to manually re-select the node to retry. This prevents indefinite re-driving of a node
* whose firmware is crashing under handshake re-entry (reproduced on T-Beam v2.7.25.104df5f).
*/
Tff7b72private Tff7b72const Tff7b72val Te6edf3MAX_CONSECUTIVE_RECOVERY_FAILURES Tff7b72= T79c0ff3

T8b949e/**
* Base delay (seconds) for the exponential backoff applied between consecutive recovery attempts in
* [runSiblingHandshakeRecovery]. Doubled for each prior failure and hard-capped at
* [RECOVERY_BACKOFF_CAP_SECONDS].
*
* 2 s base gives the firmware and transport a brief breather after a failed restart without adding perceptible
* latency to the first retry; doubling bounds the worst-case loop tightly under the 3-strike cap.
*/
Tff7b72private Tff7b72const Tff7b72val Te6edf3RECOVERY_BACKOFF_BASE_SECONDS Tff7b72= T79c0ff2L

T8b949e/**
* Hard cap (seconds) for the exponential backoff between recovery attempts.
*
* Keeps the per-attempt delay bounded even if [MAX_CONSECUTIVE_RECOVERY_FAILURES] is raised in the future. 30 s
* matches the transport-level read timeout envelope, so a backed-off retry never waits longer than the
* underlying transport would have taken to fail on its own.
*/
T8b949e// Cap unreachable while MAX_CONSECUTIVE_RECOVERY_FAILURES=3 (max computed delay is 4s);
T8b949e// retained as defense-in-depth if MAX is raised in the future.
Tff7b72private Tff7b72const Tff7b72val Te6edf3RECOVERY_BACKOFF_CAP_SECONDS Tff7b72= T79c0ff3T79c0ff0L

Tff7b72private Tff7b72const Tff7b72val Te6edf3EVENT_CONNECTED_SECONDS Tff7b72= Ta5d6ff"Ta5d6ffconnected_secondsTa5d6ff"
Tff7b72private Tff7b72const Tff7b72val Te6edf3EVENT_MESH_DISCONNECT Tff7b72= Ta5d6ff"Ta5d6ffmesh_disconnectTa5d6ff"
Tff7b72private Tff7b72const Tff7b72val Te6edf3EVENT_NUM_NODES Tff7b72= Ta5d6ff"Ta5d6ffnum_nodesTa5d6ff"
Tff7b72private Tff7b72const Tff7b72val Te6edf3EVENT_MESH_CONNECT Tff7b72= Ta5d6ff"Ta5d6ffmesh_connectTa5d6ff"

Tff7b72private Tff7b72const Tff7b72val Te6edf3KEY_NUM_NODES Tff7b72= Ta5d6ff"Ta5d6ffnum_nodesTa5d6ff"
Tff7b72private Tff7b72const Tff7b72val Te6edf3KEY_NUM_ONLINE Tff7b72= Ta5d6ff"Ta5d6ffnum_onlineTa5d6ff"
Tff7b72private Tff7b72const Tff7b72val Te6edf3KEY_RADIO_MODEL Tff7b72= Ta5d6ff"Ta5d6ffradio_modelTa5d6ff"
Tb4b4b4}
Tb4b4b4}

Served by rngit 1.4.2 - Generated in 0.13s